iT邦幫忙

2026 iThome 鐵人賽

DAY 26
1
Modern Web

Angular 22 Signal 進化論系列 第 26 篇

Day 26:SignalStore 與 Strategy Pattern,透過 DI 動態切換業務邏輯

  • 分享至 

  • xImage
  •  

昨天我們使用 Angular 原生 Signal 與 NgRx SignalStore 實作購物車,透過 State 管理商品資料,再利用 Computed 計算商品數量與總金額。

不過,當購物車需要根據不同會員套用折扣時,可能會直接在 Store 裡面增加判斷:

const finalTotal = computed(() => {
  const total = store.total();

  if (memberType() === 'vip') {
    return total * 0.9;
  }

  if (memberType() === 'business') {
    return total * 0.85;
  }

  return total;
});

目前只有三種會員,這樣寫沒有什麼問題。但如果未來增加更多折扣規則,Store 就需要不斷修改。

今天我們會使用 Strategy Pattern(策略模式) 拆分折扣計算,再搭配 Angular Dependency Injection(DI)與 Signal,讓會員類型改變時,可以動態切換對應的折扣規則。

Strategy Pattern 是什麼?

Strategy Pattern 是一種行為型設計模式,主要是將不同的演算法或業務規則封裝成獨立物件,讓使用端透過共同介面操作。

以購物車為例,假設有三種會員:

  • 一般會員:原價。
  • VIP 會員:9 折。
  • 企業會員:85 折。

三種會員的計算方式不同,但都需要根據總金額計算折扣,因此可以先定義共同介面。

定義折扣策略

discount.strategy.ts

import { Injectable } from '@angular/core';

export interface DiscountStrategy {
  calculate(total: number): number;
}

@Injectable({ providedIn: 'root' })
export class NormalDiscount implements DiscountStrategy {
  calculate(total: number) {
    return total;
  }
}

@Injectable({ providedIn: 'root' })
export class VipDiscount implements DiscountStrategy {
  calculate(total: number) {
    return total * 0.9;
  }
}

@Injectable({ providedIn: 'root' })
export class BusinessDiscount implements DiscountStrategy {
  calculate(total: number) {
    return total * 0.85;
  }
}

每個 Class 都實作 DiscountStrategy,提供相同的 calculate() 方法,但使用不同的折扣規則。未來新增其他折扣時,只需要建立新的 Strategy,不必將所有判斷集中在 Store 裡。

這裡透過 @Injectable({ providedIn: 'root' }) 將三個 Strategy 註冊到 Angular DI。它們通常會在第一次被注入時才建立實體,之後由 Root Injector 共用,而不是 Application 啟動時就全部建立。

下圖整理三個 Strategy 與共同介面的關係,無論使用哪種折扣規則,都能透過相同的 calculate() 方法計算。

Strategy Pattern 折扣策略:三個 Strategy 實作同一個 DiscountStrategy 介面

使用 Service 動態切換 Strategy

將折扣規則拆開後,還需要根據目前的會員類型,選擇要使用哪一種 Strategy。

這次建立 DiscountService,透過 Signal 記錄會員類型,並利用 Angular DI 取得三個折扣策略。

建立 DiscountService

discount.service.ts

import { Injectable, inject, signal } from '@angular/core';

import {
  NormalDiscount,
  VipDiscount,
  BusinessDiscount
} from './discount.strategy';

import type { DiscountStrategy } from './discount.strategy';

export type MemberType = 'normal' | 'vip' | 'business';

@Injectable({ providedIn: 'root' })
export class DiscountService implements DiscountStrategy {
  readonly memberType = signal<MemberType>('normal');

  private readonly strategies = {
    normal: inject(NormalDiscount),
    vip: inject(VipDiscount),
    business: inject(BusinessDiscount)
  };

  calculate(total: number) {
    return this.strategies[
      this.memberType()
    ].calculate(total);
  }

  change(type: MemberType) {
    this.memberType.set(type);
  }
}

DiscountService 本身也實作了 DiscountStrategy,但不直接計算折扣,而是根據 memberType() 選擇對應的 Strategy。

這裡透過 inject() 取得三個 Strategy,因此當 DiscountService 第一次建立時,就會一併取得這些實體。之後呼叫 change() 切換會員類型時,只需要改用對應的 Strategy,不需要重新建立。

例如目前是 VIP 會員,就使用 VipDiscount;當呼叫 change('business') 後,便改由 BusinessDiscount 計算。

透過 Angular DI 提供 Strategy

有了 DiscountService 後,我們希望 SignalStore 只需要使用 calculate(),不必直接依賴 DiscountService 這個 Class。

由於 TypeScript Interface 在編譯後不會保留,無法直接作為 Angular DI 的 Token,因此需要建立 InjectionToken。

discount.token.ts

import { InjectionToken } from '@angular/core';
import type { DiscountStrategy } from './discount.strategy';

export const DISCOUNT_STRATEGY =
  new InjectionToken<DiscountStrategy>('DISCOUNT_STRATEGY');

DISCOUNT_STRATEGY 是注入時使用的 Token,泛型則指定取得的物件需要符合 DiscountStrategy 介面。

接著在 app.config.ts 註冊:

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';

import { routes } from './app.routes';
import { DISCOUNT_STRATEGY } from './cart/discount.token';
import { DiscountService } from './cart/discount.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
    {
      provide: DISCOUNT_STRATEGY,
      useExisting: DiscountService
    }
  ]
};

這裡使用 useExisting,讓 DISCOUNT_STRATEGY 成為 DiscountService 的另一個注入名稱。由於 DiscountService 已經透過 providedIn: 'root' 註冊,因此不需要再加入 providers。

useExisting 與 useClass 的差別在於是否共用實體:

Provider 說明
useClass 由 DI 為該 Token 建立指定 Class 的實體
useExisting 使用另一個 Token 對應的同一個實體

如果改成 useClass: DiscountService,Angular 就會為 DISCOUNT_STRATEGY 建立另一個 DiscountService 實體,與原本透過 Root 註冊的實體分開管理。

這次使用 useExisting,是因為 Component 需要切換會員類型,而 SignalStore 需要根據相同的會員狀態計算折扣,因此兩者必須取得同一個 DiscountService 實體。

Angular DI useExisting:CartComponent 與 NgRxCartStore 共用同一個 DiscountService 實體

在 SignalStore 使用 Strategy

延續昨天的 NgRxCartStore,保留原本的購物車功能,另外增加折扣後的 finalTotal。

ngrx-cart.store.ts

import { computed, inject } from '@angular/core';
import {
  signalStore,
  withState,
  withComputed,
  withMethods,
  patchState
} from '@ngrx/signals';

import type { CartItem, Product } from './cart.types';
import { DISCOUNT_STRATEGY } from './discount.token';

export const NgRxCartStore = signalStore(
  { providedIn: 'root' },

  withState({
    items: [] as CartItem[]
  }),

  withComputed(({ items }) => {
    const discount = inject(DISCOUNT_STRATEGY);

    const count = computed(() =>
      items().reduce(
        (sum, item) => sum + item.quantity,
        0
      )
    );

    const total = computed(() =>
      items().reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      )
    );

    const finalTotal = computed(() =>
      discount.calculate(total())
    );

    return { count, total, finalTotal };
  }),

  withMethods(store => ({
    add(product: Product) {
      patchState(store, state => {
        const existing = state.items.find(
          item => item.id === product.id
        );

        const items = existing
          ? state.items.map(item =>
              item.id === product.id
                ? { ...item, quantity: item.quantity + 1 }
                : item
            )
          : [...state.items, { ...product, quantity: 1 }];

        return { items };
      });
    },

    remove(id: number) {
      patchState(store, state => ({
        items: state.items.filter(item => item.id !== id)
      }));
    },

    clear() {
      patchState(store, { items: [] });
    }
  }))
);

這次主要新增:

const discount = inject(DISCOUNT_STRATEGY);

const finalTotal = computed(() =>
  discount.calculate(total())
);

inject(DISCOUNT_STRATEGY) 會透過 Angular DI 取得前面註冊的 DiscountService 實體,再由 calculate() 計算折扣後的金額。

這裡也可以直接使用 inject(DiscountService),但透過 DISCOUNT_STRATEGY,Store 只需要知道 calculate(),不必依賴 DiscountService 的其他功能。未來如果更換成其他符合介面的折扣服務,也不需要修改 Store。

Component 切換會員類型

最後在 Component 加入會員選單,讓使用者可以切換折扣。

cart.ts

import { Component, inject } from '@angular/core';

import { NgRxCartStore } from './ngrx-cart.store';
import { DiscountService } from './discount.service';
import type { MemberType } from './discount.service';

@Component({
  selector: 'app-root',
  template: `
    <main style="padding: 24px; max-width: 600px;">
      <h1>Strategy Pattern 購物車</h1>

      <h3>商品列表</h3>

      <button (click)="store.add({
        id: 1,
        name: '鍵盤',
        price: 1200
      })">
        加入鍵盤 $1200
      </button>

      <button (click)="store.add({
        id: 2,
        name: '滑鼠',
        price: 800
      })">
        加入滑鼠 $800
      </button>

      <hr />

      <h3>會員類型</h3>

      <select
        [value]="discount.memberType()"
        (change)="changeMember($event)"
      >
        <option value="normal">一般會員</option>
        <option value="vip">VIP 會員</option>
        <option value="business">企業會員</option>
      </select>

      <hr />

      <h3>購物車</h3>

      @for (item of store.items(); track item.id) {
        <p>
          {{ item.name }} × {{ item.quantity }}
          = {{ item.price * item.quantity }} 元

          <button (click)="store.remove(item.id)">
            移除
          </button>
        </p>
      } @empty {
        <p>購物車目前沒有商品</p>
      }

      <hr />

      <p>商品數量:{{ store.count() }}</p>
      <p>原價:{{ store.total() }} 元</p>
      <h2>折扣後:{{ store.finalTotal() }} 元</h2>

      <button (click)="store.clear()">
        清空購物車
      </button>
    </main>
  `
})
export class CartComponent {
  readonly store = inject(NgRxCartStore);
  readonly discount = inject(DiscountService);

  changeMember(event: Event) {
    const select = event.target as HTMLSelectElement;
    this.discount.change(select.value as MemberType);
  }
}

Component 直接注入 DiscountService,因為需要呼叫 change() 切換會員類型;SignalStore 則透過 DISCOUNT_STRATEGY 使用同一個實體來計算折扣。

假設購物車有一個 1,200 元的鍵盤,以及一個 800 元的滑鼠:

會員類型 折扣 結帳金額
一般會員 原價 2,000 元
VIP 會員 9 折 1,800 元
企業會員 85 折 1,700 元

切換會員類型後,購物車商品與原價都不會改變,只有折扣金額會跟著更新。

Runtime 切換折扣策略:切換 memberType 後選擇對應 Strategy,finalTotal 自動更新

Angular SignalStore 與 Strategy Pattern,透過 DI 動態切換業務邏輯 - StackBlitz

為什麼切換後會自動更新?

回頭看 SignalStore 裡面的 finalTotal:

const finalTotal = computed(() =>
  discount.calculate(total())
);

雖然這裡沒有直接讀取 memberType(),但 DiscountService 的 calculate() 內部會使用這個 Signal:

calculate(total: number) {
  return this.strategies[
    this.memberType()
  ].calculate(total);
}

Angular 的 computed() 會追蹤計算過程中讀取的 Signal,即使透過其他方法間接讀取,也一樣會建立依賴關係。

因此,finalTotal 同時依賴購物車的 total() 與會員類型的 memberType()。當其中一個改變時,就會在下次讀取時重新計算。

需要注意的是,Runtime 切換的是 DiscountService 內部使用的 Strategy,而不是 Angular DI Provider 本身。DI 負責提供實體,Signal 則讓折扣計算能隨著會員類型改變。

這次為了示範,讓使用者可以直接切換會員類型。實際系統中的會員等級通常由登入資訊或後端資料決定,最終結帳金額也應由後端驗證。

本日小結

今天透過 Strategy Pattern,將不同會員的折扣規則拆成獨立 Class,讓 SignalStore 不需要處理各種折扣判斷,只需要呼叫共同的 calculate() 方法。

Angular DI 負責建立與管理 Strategy,DiscountService 則透過 inject() 取得各個折扣實體。搭配 InjectionToken 與 useExisting,即使 SignalStore 與 Component 注入不同的 Token,也能共用同一個 Service。

當會員類型改變時,DiscountService 會選擇對應的 Strategy,而 SignalStore 的 Computed 也會依照最新狀態重新計算金額,不需要重新建立 Store 或替換 DI Provider。

這個範例將折扣規則與購物車狀態分開管理。未來需要增加新的會員折扣時,可以擴充 Strategy,讓原本的 Store 不必跟著調整。

資料來源


上一篇
Day 25:Angular SignalStore,原生 Signal 不夠用嗎?
下一篇
Day 27:NgRx Store vs SignalStore,從 Action、Reducer 到直接更新 State
系列文
Angular 22 Signal 進化論 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言